import os, sys, string, random, glob, re, ConfigParser
from mercurial import hg, cmdutil, util, lock, match

from messages import *

BT_VERSION = "0.2"
BT_PATH    = ".hgbugs"
BT_FILE    = "info"
BT_OPENED  = "opened"
BT_CLOSED  = "closed"

TEMPLATE_BUG = """\
%s
%s
BT: ---
BT: Enter the bug information. 
BT: Lines starting wit "BT:" will be ignored.
BT: First line will be the bug title. Subsequent lines will fill the description.
BT: Do not change the file to abort the operation.
"""

def remove_bt_lines( s ):
	"""Removes all lines of string 's' starting with 'BT:'."""
	import re
	return re.sub("(?m)^BT:.*\n", "", s )



def check_initialized( message, abort = False ):
	""" This is a decorator function.
		Checks if an object is initialized, calling self.initialized(). If it is 
		not initialized, show "message" either as an exception (if "abort" is
		true ) or as a warning using 'self.ui' 
	"""
	def wrap( f ):		
		def func( *args ):
			self = args[0]
			if not self.is_initialized():
				if abort:
					raise util.Abort( message )
				else:
					self.ui.warn( message )
			return f( *args )
		return func
	return wrap
	
def remove_linebreaks( s ):
	""" Remove all linebreaks (both "\n" and "\r" from a string """
	return s.replace( "\n", "" ).replace( "\r", "" )
	
def ellipsize_text( s, size = 60 ):
	return s if len( s ) <= size else s[:(size-3)] + "..."
	
def upgrade_bugtracker_metadata_01_02( bt ):
	"""This function upgrades bug tracking metadata from version 0.1 to 0.2
	
	Version 0.1 of the bugtracking extension had a major bug when adding files
	to the repository and latter commiting them, rendering these files incorrectly
	added (but correctly tracked). The files are added using the backwards slash
	instead of the forward slash, that caused incorrect behaviour mostly found
	when using 'hg serve' and exploring the files via web.
	
	To solve it, we need to readd all files, using the correct forward slash and
	remove old references to these files in the repo.
	"""
	
	if bt.version() != "0.1":
		return
	
	def p( str ):
		bt.ui.write( str )
		
	wlock = bt.repo.wlock( False )
	try:
		p( "Upgrading bug metadata...\n" )
		
		for f in bt.repo[None]:
			nf = f.replace( '\\', '/' )
			if f != nf:
				p( "  Upgrade: %s => %s ... " % (f, nf) )
				bt.repo.copy( f, nf )
				bt.repo.remove( [f] )
				p( "done!.\n" )
		bt._config["version"] = "0.2"
		bt._save()
		bt.commit( "Bug metadata upgraded: 0.1 => 0.2" )	
	finally:
		wlock.release()
	
def upgrade_bugtracker_metadata( bt, do_upgrade ):
	def p( str ):
		bt.ui.write( str )
		
	if not do_upgrade:
		return
			
	p("******************************************\n")
	p("**   YOUR BUGTRACKING METADATA IS OLD   **\n")
	p("******************************************\n")
	p(" * Your bugtracking format version:  %s\n" % bt.version() )
	p(" * Current extension format version: %s\n" % BT_VERSION )
	p("\n")
	
	if bt.version() == "0.1":
		if not do_upgrade:
			raise util.Abort( MSG_BugTrackMetadataVersionOld )
		upgrade_bugtracker_metadata_01_02( bt )
	elif bt.version() == BT_VERSION:
		p( "Your bug tracking metadata is already upgraded!\n" )
		return
	else:
		raise util.Abort( MSG_BugTrackMetadataVersionInvalid )

class Bug( object ):	
	def __init__( self, bt, buid, dry_run = False ):
		self._bt = bt
		self._buid = buid
		self._dry_run = dry_run
		self._info = {}
		self._info[ "title" ] = BUG_DEFAULT_TITLE
		self._info[ "description" ] = BUG_DEFAULT_DESCRIPTION
		self._info[ "created-by" ]  = BUG_DEFAULT_CREATOR
		self._info[ "owned-by" ]    = BUG_DEFAULT_OWNER
		self._info[ "assigned-to" ] = BUG_DEFAULT_ASSIGNED_TO
		self._loaded = False
		
		if bt.is_bug_closed( buid ):
			self._status = BUG_STATUS_CLOSED
			self._load()
		elif bt.is_bug_opened( buid ):
			self._status = BUG_STATUS_OPENED
			self._load()
		else:
			self._status = BUG_STATUS_OPENED
			
	def _load( self ):
		if self._loaded:
			return
		cfg = ConfigParser.SafeConfigParser()
		cfg.read( self._bt.repo.wjoin( self.path() ) )
		for o in cfg.options( "info" ):
			try:
				self._info[o] = cfg.get( "info", o )
			except:
				raise util.Abort( "Error loading bug. '%s' is invalid in bug (#%s): %s" % (o ,self.buid(), self.path()) )
			
	def assigned_to( self ):
		return self._info[ "assigned-to" ]
			
	def buid( self ):
		return self._buid
		
	def created_by( self ):
		return self._info[ "created-by" ]
			
	def description( self ):
		return self._info[ "description" ]
		
	def owned_by( self ):
		return self._info[ "owned-by" ]
				
	def assigned_letter( self ):
		if self._info[ "assigned-to" ] == '':
			return ' '
		if self._info[ "assigned-to" ] == self._bt.ui.username() or self._info[ "assigned-to" ] == self._bt.ui.shortuser():
			return 'I'
		return 'O'

	def path( self ):
		if self._status == BUG_STATUS_OPENED:
			base_path = self._bt.path_opened
		elif self._status == BUG_STATUS_CLOSED:
			base_path = self._bt.path_closed
		else:
			raise util.Abort( MSG_BugNoValidStatus )
		return os.path.join( base_path, self._buid )
	
	def print_status( self ):
		ui = self._bt.ui
		ui.write( "* Title:\n    %s\n" % self.title() )
		ui.write( "* Bug ID: %s\n" % self.buid() )
		ui.write( "* Status: %s\n" % self._status )
		ui.write( "* Created by: %s\n" % self.created_by() )
		ui.write( "* Owned by: %s\n" % self.owned_by() )
		ui.write( "* Assigned to: %s\n" % self.assigned_to() )
		ui.write( "* Description:\n" )
		for line in self.description().splitlines():
			ui.write( "    %s\n" % line )
		
	def reload( self ):
		self._loaded = False
		self._load()
		
	def save( self ):
		if self._dry_run:
			self._bt.ui.write( "DRY-RUN: Ignored store of bug (#%s)\n" % self.buid() )
			return
		add_to_repo = not self._bt.exists_bug( self.buid() )
		cfg = ConfigParser.SafeConfigParser()
		cfg.add_section( "info" )
		for o in self._info:
			cfg.set( "info", o, self._info[o] )
		f = open( self._bt.repo.wjoin( self.path() ), "w+" )
		cfg.write( f )
		f.close()
		# Add file to mercurial
		if add_to_repo:
			self._bt.repo[None].add( [self.path().replace('\\','/')] )
		
	def title( self ):
		return self._info[ "title" ]
		
	def set_assigned_to( self, value ):
		self._info[ "assigned-to" ] = value
		
	def set_created_by( self, value ):
		self._info[ "created-by" ] = value
	
	def set_owned_by( self, value ):
		self._info[ "owned-by" ] = value
		
	def set_description( self, desc ):
		self._info[ "description" ] = desc
		
	def set_title( self, title ):
		self._info[ "title" ] = title
		

class BugTracker( object ):
	def __init__( self, ui, repo, dry_run = False, upgrade = False ):
		self._config = {}
		self._config[ "version" ] = BT_VERSION
		self._loaded = False
		self.ui      = ui
		self.repo    = repo
		self.dry_run = dry_run
		self.path    = BT_PATH
		self.path_opened = os.path.join( self.path, BT_OPENED )
		self.path_closed = os.path.join( self.path, BT_CLOSED )
		self.filename = os.path.join(BT_PATH, BT_FILE)
		self.text_width = int(self.ui.config('bugtracker', 'text-width', 74))
		if self.is_initialized():
			self._load()
			# Check for versions supported
			upgrade_bugtracker_metadata( self, upgrade )
		
	@check_initialized( MSG_BugTrackIsNotInitialized, True )
	def _load( self ):
		if self._loaded:
			return
		cfg = ConfigParser.SafeConfigParser()
		cfg.read( self.repo.wjoin( self.filename ) )
		for o in cfg.options( "config" ):
			self._config[o] = cfg.get( "config", o )
		# Check if "opened" and "closed" paths exists, and create them if not
		if not os.path.exists( self.repo.wjoin( self.path_opened ) ):
			os.mkdir( self.repo.wjoin( self.path_opened ) )
		if not os.path.exists( self.repo.wjoin( self.path_closed ) ):
			os.mkdir( self.repo.wjoin( self.path_closed ) )
		self._loaded = True			
			
	def _save( self ):
		if self.dry_run:
			self.ui.write( "DRY-RUN: save bugtracking metadata.\n" )
			return
			
		cfg = ConfigParser.SafeConfigParser()
		cfg.add_section( "config" )
		for o in self._config:
			cfg.set( "config", o, self._config[o] )
		f = open( self.repo.wjoin( self.filename ), "w+" )
		cfg.write( f )
		f.close()
		fname = self.filename.replace('\\','/') 
		if self.repo.dirstate[fname] == '?':
			self.repo[None].add([fname])
		
	def _get_files_in_path( self, path, pattern = "*" ):
		#print "Finding files in path (pattern = %s: %s)" % ( pattern, path )
		files = glob.glob( os.path.join( path, pattern ) )
		i = 0
		while i < len( files ):
			(dumb, files[i]) = os.path.split( files[i] )
			i = i+1			
		return files
		
	@check_initialized( MSG_BugTrackIsNotInitialized, True )
	def add_bug( self, title ):
		buid = self.create_buid()
		bug = Bug( self, buid, self.dry_run )
		bug.set_title( title )
		bug.set_created_by( self.ui.username() )
		bug.save()
		if not self.dry_run:
			self.ui.status( MSG_BugAdded % (buid, bug.title()) )
		else:
			self.ui.write( "DRY-RUN: %s\n" % (MSG_BugAdded % (buid, bug.title())) )		
		return bug
		
	@check_initialized( MSG_BugTrackIsNotInitialized, True )
	def close_bug( self, buid ):
		# self.get_bug takes into account bug prefixes
		bug = self.get_bug( buid )
		
		if self.is_bug_closed( bug.buid() ):
			self.ui.warn( MSG_BugAlreadyClosed % bug.buid() )
			return None
		
		if self.dry_run:
			self.ui.write( "DRY-RUN: %s\n" % (MSG_BugClosed % bug.buid()) )
			return bug
		
		wlock = self.repo.wlock( False )
		try:
			cmdutil.copy( 
					self.ui, 
					self.repo, 
					[ self.repo.wjoin( bug.path() ), self.repo.wjoin( os.path.join( self.path_closed, bug.buid() ) ) ], 
					{}, 
					rename=True 
				)
		finally:
			wlock.release()
		self.ui.status( MSG_BugClosed % (bug.buid(), bug.title()) )
		return bug
		
	@check_initialized( MSG_BugTrackIsNotInitialized, True )
	def commit( self, message ):
		u = self.ui.username()
		e = cmdutil.commiteditor
		m = match.match( self.repo, self.repo.getcwd(), ["re:^%s" % BT_PATH] )
				
		if not self.dry_run:
			self.repo.commit( message, user = u, match = m, editor = e)
			self.ui.write( "\n" )
		else:
			self.ui.write( "DRY-RUN: Commit changes.\n" )
		
	@check_initialized( MSG_BugTrackIsNotInitialized, True )
	def create_buid( self ):
		while True:
			buid = (util.sha1(str(random.random()))).hexdigest()[:8].lower()
			if not self.exists_bug( buid ):
				return buid

	@check_initialized( MSG_BugTrackIsNotInitialized, True )
	def edit_bug( self, bug, template = "%s\n%s" ):
		# Set the template text of the bug
		ptext = template % ( bug.title(), bug.description() )
				
		# Let the user edit the bug
		text =  self.ui.edit( ptext, self.ui.username() )
		if text == ptext:
			raise util.Abort( "Cancelled: nothing changed!" )
				
		text  = remove_bt_lines( text )
		ptext = remove_bt_lines( ptext )
		
		# Read back all data (possibly) edited by the user
		( title, sep, desc ) = text.partition( "\n" )
		title = title.strip()
		desc  = desc.strip()
		if title == "":
			raise util.Abort( "Cancelled: no title specified!" )
		
		bug.set_title( title )
		bug.set_description( desc )
		
	@check_initialized( MSG_BugTrackIsNotInitialized, True )
	def exists_bug( self, buid ):
		return self.is_bug_opened( buid ) or self.is_bug_closed( buid )
		
	@check_initialized( MSG_BugTrackIsNotInitialized, True )
	def get_bug( self, buid ):
		if not self.exists_bug( buid ):
			# buid is not a BugID, so try to guess the bug using "buid" as prefix
			r  = self.repo.root
			po = self.path_opened
			pc = self.path_closed
			matched = self._get_files_in_path( os.path.join( r, po ), buid + "*" )
			matched.extend( self._get_files_in_path( os.path.join( r, pc ), buid + "*" ) )
			total = len( matched )			
			if total == 0:
				raise util.Abort( MSG_BugPrefixOrIDInvalid % buid )
			elif total > 1:
				raise util.Abort( MSG_BugPrefixAmbiguous % (buid, total, buid) )
			else:
				return Bug( self, matched[0] )
		return Bug( self, buid, self.dry_run )
		
	@check_initialized( MSG_BugTrackIsNotInitialized, True )
	def get_opened_buid_list( self ):
		return self._get_files_in_path( os.path.join( self.repo.root, self.path_opened ) )

	@check_initialized( MSG_BugTrackIsNotInitialized, True )
	def get_closed_buid_list( self ):
		return self._get_files_in_path( os.path.join( self.repo.root, self.path_closed ) )
		
	@check_initialized( MSG_BugTrackIsNotInitialized, True )
	def get_options( self ):
		self._load()
		return self._options
		
	def initialize( self, forced = False ):
		""" Initializes the bugtracking metadata if it's not already initialized.
		Set "forced" to create an empty bugtracking metadata although previous initialization.
		"""
		if self.is_initialized() and not forced:
			self.ui.write( MSG_BugTrackIsAlreadyInitialized )
			return
		if not os.path.exists( self.repo.wjoin( self.path ) ):
			os.mkdir( self.repo.wjoin( self.path ) )
		self._save()
		
	@check_initialized( MSG_BugTrackIsNotInitialized, True )
	def is_bug_closed( self, buid ):
		in_closed = self.repo.wjoin( os.path.join( self.path_closed, buid ) )
		return os.path.exists( in_closed )
		
	@check_initialized( MSG_BugTrackIsNotInitialized, True )
	def is_bug_opened( self, buid ):
		in_opened = self.repo.wjoin( os.path.join( self.path_opened, buid ) )
		return os.path.exists( in_opened )
		
	def is_initialized( self ):
		return os.path.exists( self.repo.wjoin( self.filename ) )
		
	@check_initialized( MSG_BugTrackIsNotInitialized, True )
	def open_bug( self, buid ):
		# self.get_bug() checks not only bug id's, bug also bug id prefixes
		bug = self.get_bug( buid )
	
		if self.is_bug_opened( bug.buid() ):
			self.ui.warn( MSG_BugAlreadyOpened % bug.buid() )		
			return None
			
		if self.dry_run:
			self.ui.write( "DRY-RUN: %s\n" % (MSG_BugOpened % bug.buid()) )
			return bug
		
		wlock = self.repo.wlock( False )
		try:
			cmdutil.copy( 
				self.ui, 
				self.repo, 
				[ self.repo.wjoin( bug.path() ), self.repo.wjoin( os.path.join( self.path_opened, bug.buid() ) ) ], 
				{}, 
				rename=True )
		finally:
			wlock.release()
		self.ui.status( MSG_BugOpened % bug.buid() )
		return bug
				
	def reload( self ):
		"""Reloads the bugtracker metadata"""
		self._loaded = False
		self._load()
		
	def version( self ):
		"""Obtains the version string of the bugtracker"""
		return self._config[ "version" ]
		
	@check_initialized( MSG_BugTrackIsNotInitialized, True )
	def find_bugs( self, 
			bug_prefix = "",
			include_opened = True, 
			include_closed = True, 
			limit = 5,
			created_by = None,
			assigned_to = None,
			owned_by = None
		):
		""" Find bugs matching parameters, and returns an array Bugs """
		if limit < 1: limit = sys.maxint
		
		result = {}
		result["filters"] = {}
		result["filters"]["prefix"] = bug_prefix
		result["filters"]["include_opened"] = include_opened
		result["filters"]["include_closed"] = include_closed
		result["filters"]["limit"] = limit
		result["filters"]["created_by"] = created_by
		result["filters"]["assigned_to"] = assigned_to
		result["filters"]["owned_by"] = owned_by
		
		buids_opened = self.get_opened_buid_list()
		buids_closed = self.get_closed_buid_list()
		result["totals"] = {}
		result["totals"]["opened"] = len( buids_opened )
		result["totals"]["closed"] = len( buids_closed )
		result["totals"]["total"] = result["totals"]["opened"] + result["totals"]["closed"]
		
		if not include_opened: buids_opened = {}
		if not include_closed: buids_closed = {}		
		
		opened_count = len( buids_opened )
		closed_count = len( buids_closed )
		total_count  = opened_count + closed_count
		
		def filter_fn( buid, prefix, created_by, assigned_to ):
			if not buid.startswith( bug_prefix ): return (False, None)
			b = Bug( self, buid )
			if created_by is not None:
				if created_by == "":
					if not (b.created_by() == ""): return (False, b)
				else:
					if not created_by in b.created_by(): return (False, b)
			if assigned_to is not None:
				if assigned_to == "":
					if not (b.assigned_to() == ""): return (False, b)
				else:
					if not assigned_to in b.assigned_to(): return (False, b)
			if owned_by is not None:
				if owned_by == "":
					if not (b.owned_by() == ""): return (False, b)
				else:
					if not owned_by in b.owned_by(): return (False, b)
			return (True, b)
		
		# filter out non-matched bugs		
		result["opened"] = []
		for buid in buids_opened:
			( passed, bug ) = filter_fn( buid, bug_prefix, created_by, assigned_to )
			if passed: result["opened"].append( bug )
				
		result["closed"] = []
		for buid in buids_closed:
			( passed, bug ) = filter_fn( buid, bug_prefix, created_by, assigned_to )
			if passed: result["closed"].append( bug )
			
		# limit items, store before-limit counts
		result["opened_count"] = len( result["opened"] )
		result["closed_count"] = len( result["closed"] )
		result["total_count"] = result["opened_count"] + result["closed_count"]
		result["opened"] = result["opened"][:limit]
		result["closed"] = result["closed"][:limit]
		
		return result
		
	@check_initialized( MSG_BugTrackIsNotInitialized, True )
	def print_bugs( self, bugs ):
		"""Prints a list of bugs using 'self.ui'"""
		
		# Show statistics
		self.ui.write( "Totals: %d opened, %d closed (%d total)\n" % (bugs["totals"]["opened"], bugs["totals"]["closed"], bugs["totals"]["total"]) )
		
		if bugs["totals"]["opened"] != bugs["opened_count"] or bugs["totals"]["closed"] != bugs["closed_count"]:
			# Some filtering has been applied, show notify filtering options:
			if bugs["filters"]["prefix"] != "":
				self.ui.write( " - Using prefix: %s\n" % bugs["filters"]["prefix"] )
			if bugs["filters"]["include_opened"]:
				self.ui.write( " - Including 'open' bugs.\n" )
			if bugs["filters"]["include_closed"]:
				self.ui.write( " - Including 'closed' bugs.\n" )
			#if bugs["filters"]["limit"] != sys.maxint:
			#	self.ui.write( "Limit results to %d.\n" % bugs["filters"]["limit"] )
			if bugs["filters"]["created_by"] is not None:
				self.ui.write( " - Created by '%s'.\n" % bugs["filters"]["created_by"] )
			if bugs["filters"]["assigned_to"] is not None:
				self.ui.write( " - Assigned to '%s'.\n" % bugs["filters"]["assigned_to"] )
			if bugs["filters"]["owned_by"] is not None:
				self.ui.write( " - Owned by '%s'.\n" % bugs["filters"]["owned_by"] )
			self.ui.write( "Passed: %d opened, %d closed (%d total)\n" % ( bugs["opened_count"], bugs["closed_count"], bugs["total_count"] ) )

		def print_fn_simple( bug ):
			self.ui.write( " %s (%s) %s\n" % ( bug.assigned_letter(), bug.buid(), ellipsize_text( bug.title(), self.text_width - 14 ) ) )
		print_fn = print_fn_simple
		
		# Print actual data:
		opened_count = len( bugs["opened"] )
		opened_diff  = bugs["opened_count"] - opened_count
		if opened_count > 0:
			self.ui.write( "\nOpened bugs:\n" )
			for bug in bugs["opened"]: 
				print_fn( bug )
			if opened_diff > 0:
				self.ui.write( "  ... and %d more.\n" % opened_diff )
			
		closed_count = len( bugs["closed"] )
		closed_diff  = bugs["closed_count"] - closed_count
		if closed_count > 0:
			self.ui.write( "\nClosed bugs:\n" )
			for bug in bugs["closed"]:
				print_fn( bug )
			if closed_diff:
				self.ui.write( "  ... and %d more.\n" % closed_diff )
